/*
 * Copyright (c) 2018, apexes.net. All rights reserved.
 *
 *         http://www.apexes.net
 *
 */
package net.apexes.commons.querydsl;

import com.querydsl.core.types.OrderSpecifier;
import com.querydsl.core.types.Path;
import com.querydsl.core.types.dsl.BooleanExpression;
import com.querydsl.core.types.dsl.SimpleExpression;
import net.apexes.commons.lang.Checks;
import net.apexes.commons.querydsl.sql.TablePathBase;

import java.beans.BeanInfo;
import java.beans.IntrospectionException;
import java.beans.Introspector;
import java.beans.PropertyDescriptor;
import java.io.Serializable;
import java.lang.reflect.Field;
import java.sql.Blob;
import java.sql.Clob;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;

/**
 * @param <T>
 * @param <ID>
 * @author <a href="mailto:hedyn@foxmail.com">HeDYn</a>
 */
public class QuerydslHelper<T, ID extends Serializable> {

    private final TablePathBase<T> qvar;
    private final Class<T> entityClass;

    private final Map<String, PathInfo> pathInfoFinder;
    private final Path<?>[] allPathArr;
    private final Path<ID> pkPath;
    private final Path<?>[] withoutPkPathArr;
    private final boolean hasBlobClobPath;

    @SuppressWarnings("unchecked")
    public QuerydslHelper(TablePathBase<T> qvar) {
        this.qvar = qvar;
        this.entityClass = (Class<T>) qvar.getType();

        HashMap<String, PropertyDescriptor> propMap = new LinkedHashMap<>();
        HashMap<String, Path<?>> pathMap = new LinkedHashMap<>();
        HashMap<String, Field> fieldMap = new LinkedHashMap<>();

        for (Path<?> path : qvar.getColumns()) {
            String name = pathName(path);
            pathMap.put(name, path);
            Field field = findField(entityClass, name);
            if (field == null) {
                throw new RuntimeException(entityClass.getName() + "." + name + " not found!");
            }
            fieldMap.put(name, field);
        }

        BeanInfo beanInfo;
        PropertyDescriptor[] descriptors;
        try {
            beanInfo = Introspector.getBeanInfo(entityClass);
            descriptors = beanInfo.getPropertyDescriptors();
            if (descriptors == null) {
                descriptors = new PropertyDescriptor[0];
            }
        } catch (IntrospectionException e) {
            descriptors = new PropertyDescriptor[0];
        }
        for (PropertyDescriptor desc : descriptors) {
            String propName = desc.getName();
            if ("class".equalsIgnoreCase(propName)) {
                continue;
            }
            propMap.put(propName, desc);
        }

        if (pathMap.isEmpty()) {
            throw new RuntimeException(entityClass.getName() + " paths is empty");
        }

        boolean blobClobPath = false;
        this.pathInfoFinder = new LinkedHashMap<>();
        for (Map.Entry<String, Path<?>> entry : pathMap.entrySet()) {
            String name = entry.getKey();
            Path<?> path = entry.getValue();
            Field field = fieldMap.get(name);
            PropertyDescriptor desc = propMap.get(name);
            PathInfo pathInfo = new PathInfo(path, field, desc);
            if (pathInfo.blobClobPath) {
                blobClobPath = true;
            }
            pathInfoFinder.put(name, pathInfo);
        }
        this.hasBlobClobPath = blobClobPath;

        int qvarColumnCount = qvar.getColumns().size();
        if (qvarColumnCount != pathInfoFinder.size()) {
            throw new RuntimeException(entityClass.getName() + " column count is wrong. "
                    + qvarColumnCount + " <> " + pathInfoFinder.size());
        }

        List<? extends Path<?>> pkPaths = qvar.getPrimaryKey().getLocalColumns();
        if (pkPaths.size() != 1) {
            throw new RuntimeException(entityClass.getName() + " primary key must be the only.");
        }
        pkPath = (Path<ID>) pkPaths.get(0);
        PathInfo pkPathInfo = pathInfoFinder.get(pkPath.getMetadata().getName());
        if (pkPathInfo == null) {
            throw new RuntimeException(entityClass.getName() + " primary key count is wrong.");
        }
        List<Path<?>> allPaths = new ArrayList<>();
        List<Path<?>> withoutPkPaths = new ArrayList<>();
        for (PathInfo info : pathInfoFinder.values()) {
            allPaths.add(info.path);
            if (!pkPathInfo.name.equals(info.name)) {
                withoutPkPaths.add(info.path);
            }
        }
        withoutPkPathArr = withoutPkPaths.toArray(new Path<?>[0]);
        allPathArr = allPaths.toArray(new Path<?>[0]);
    }

    public TablePathBase<T> getQvar() {
        return qvar;
    }

    public Class<T> getEntityClass() {
        return entityClass;
    }

    public Path<ID> pkPath() {
        return pkPath;
    }

    public Path<?>[] allPaths() {
        return allPathArr;
    }

    public Path<?>[] withoutPkColumns() {
        return withoutPkPathArr;
    }

    public boolean hasBlobClobPath() {
        return hasBlobClobPath;
    }

    public boolean isEntityColumn(OrderSpecifier<?> order) {
        return qvar.getColumns().contains(order.getTarget());
    }

    public boolean isEntityColumn(Path<?> path) {
        return qvar.getColumns().contains(path);
    }

    public boolean isPkColumn(Path<?> path) {
        return pkPath.equals(path);
    }

    public Path<?> getPath(String pathName) {
        Checks.verifyNotNull(pathName, "pathName");
        PathInfo pathInfo = pathInfoFinder.get(pathName);
        if (pathInfo != null) {
            return pathInfo.path;
        }
        return null;
    }

    @SuppressWarnings("unchecked")
    public <E> E getValue(T entity, Path<E> path) {
        PathInfo pathInfo = pathInfoFinder.get(pathName(path));
        if (pathInfo == null) {
            throw new RuntimeException(entity.getClass().getName() + " not found path " + path);
        }
        try {
            return (E) pathInfo.desc.getReadMethod().invoke(entity);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    public void setValue(T entity, Path<?> path, Object value) {
        PathInfo pathInfo = pathInfoFinder.get(pathName(path));
        if (pathInfo == null) {
            throw new RuntimeException(entity.getClass().getName() + " not found path " + path);
        }
        try {
            pathInfo.desc.getWriteMethod().invoke(entity, value);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    public Object[] entityValues(T entity) {
        List<Object> list = new ArrayList<>();
        for (PathInfo pathInfo : pathInfoFinder.values()) {
            try {
                list.add(pathInfo.desc.getReadMethod().invoke(entity));
            } catch (Exception e) {
                throw new RuntimeException(e);
            }
        }
        return list.toArray();
    }

    public String pathName(Path<?> path) {
        return path.getMetadata().getName();
    }

    public String columnName(Path<?> path) {
        return qvar.getMetadata(path).getName();
    }

    public String tableName() {
        return qvar.getTableName();
    }

    @SuppressWarnings("unchecked")
    public BooleanExpression pkValueEqExpr(ID pk) {
        Checks.verifyNotNull(pk, "pk");
        return ((SimpleExpression<ID>) pkPath).eq(pk);
    }

    public EntityProjection<T, ID> createtProjection(IncludeColumns includeColumns) {
        return new EntityProjection<>(this, qvar, includeColumns.getIncludeColumns());
    }

    public EntityProjection<T, ID> createtProjection(ExcludeColumns excludeColumns) {
        Map<Path<?>, Path<?>> excludeColumnsBag = excludeColumns.getExcludeColumnsBag();
        List<Path<?>> includeColumns = new ArrayList<>();
        for (Path<?> col : allPathArr) {
            if (!excludeColumnsBag.containsKey(col)) {
                includeColumns.add(col);
            }
        }
        Path<?>[] cols = includeColumns.toArray(new Path<?>[0]);
        return new EntityProjection<>(this, qvar, cols);
    }

    public PathValuePair<T, ID> createPathValuePair(T entity, Path<?>[] paths) {
        return new PathValuePair<>(this, entity, paths);
    }

    public PathValuePair<T, ID> createPathValuePair(T entity, IncludeColumns includeColumns) {
        return createPathValuePair(entity, includeColumns.getIncludeColumns());
    }

    public PathValuePair<T, ID> createPathValuePair(T entity, ExcludeColumns excludeColumns) {
        Map<Path<?>, Path<?>> excludeColumnsBag = excludeColumns.getExcludeColumnsBag();
        List<Path<?>> includeColumns = new ArrayList<>();
        for (Path<?> col : allPathArr) {
            if (!excludeColumnsBag.containsKey(col)) {
                includeColumns.add(col);
            }
        }
        Path<?>[] columns = includeColumns.toArray(new Path<?>[0]);
        return createPathValuePair(entity, columns);
    }

    private static Field findField(Class<?> clazz, String fieldName) {
        Field field = null;
        while (clazz != Object.class) {
            try {
                field = clazz.getDeclaredField(fieldName);
            } catch (Exception ignored) {
            }
            clazz = clazz.getSuperclass();
        }
        return field;
    }

    /**
     * @author <a href="mailto:hedyn@foxmail.com">HeDYn</a>
     */
    protected static class PathInfo {
        private final String name;
        private final Path<?> path;
        private final Field field;
        private final PropertyDescriptor desc;
        private final boolean blobClobPath;

        public PathInfo(Path<?> path, Field field, PropertyDescriptor desc) {
            if (path == null) {
                throw new RuntimeException("path is null");
            }
            if (field == null) {
                throw new RuntimeException("field is null");
            }
            if (desc == null) {
                throw new RuntimeException("desc is null");
            }
            this.name = path.getMetadata().getName();
            this.path = path;
            this.field = field;
            this.desc = desc;

            Class<?> pahtType = desc.getPropertyType();
            this.blobClobPath = pahtType == byte[].class
                    || pahtType == char[].class
                    || Blob.class.isAssignableFrom(pahtType)
                    || Clob.class.isAssignableFrom(pahtType);
        }

        @Override
        public String toString() {
            return "PathInfo [path=" + path + ", field=" + field + ", desc=" + desc + "]";
        }
    }
}
